本篇基于 Java 基础–队列同步器(AQS)),对重入锁(ReentrantLock)和读写锁(ReentrantReadWriteLock)进行解析。
重入锁
重入锁(ReentrantLock)表示支持一个线程对资源的重复加锁,也就是说一个线程可以多次获取到同一个锁。重入锁实现了 Lock 接口,内部实现是基于队列同步器(AbstractQueuedSynchronizer):
1 | public class ReentrantLock implements Lock, java.io.Serializable { |
重入锁还支持获取锁时的公平性与非公平性选择,可通过其构造方法进行设置,默认是非公平的。
1 | // 构造方法 |
关于公平与非公平:如果在绝对时间上,先获取锁的请求一定是先被满足的,那么这个锁就是公平的,反之就是非公平的。也就是说如果在同步队列中排队时间最长或者排在最前面的节点先获取到同步状态,那么就是公平的。
非公平性获取锁
重入锁默认是非公平性的,所以先看看非公平性的实现:
1 | static final class NonfairSync extends Sync { |
NonfairSync 只有上面 10 来行代码,当获取同步状态失败后就通过 acquire(1)
去获取同步状态,所以看看 nonfairTryAcquire(int acquires)
方法的实现:
1 | final boolean nonfairTryAcquire(int acquires) { |
nonfairTryAcquire(int acquires)
方法中也比较简单,首先判断同步状态是否被成功获取过,如果已经被成功获取过了就判断之前获取成功的线程和当前线程是否一样,如果一样就表示当前线程再次获取成功,并给同步状态加 1,此时的同步状态表示被成功获取的次数。
公平性获取锁
非公平性看完了就来看看公平性的实现:
1 | static final class FairSync extends Sync { |
可以发现公平与非公平获取锁的区别主要就是 hasQueuedPredecessors()
这句代码,意思是当前节点是否还有前驱节点,因为公平性获取锁表示排在前面的节点一定是先获取到锁的,所以这里多了一个判断。
同样,它们都是通过改变同步状态(加 1)来表示被获取的次数,每获取成功一次就修改一次(加 1)。
释放锁
在公平性与非公平性获取锁的时候,如果当前线程已经获取过锁了,那么就修改同步状态(加 1),所以当释放锁的时候就必然的需要减少同步状态。重入锁可以通过 unlock()
方法来释放锁:
1 | public void unlock() { |
通过上面可以看到 tryRelease()
方法才是核心,每次调用 unlock()
都会修改同步状态(减 1),当同步状态恢复到 0 时才算是真正的释放锁成功。
可以发现,重入锁是个排他锁,当前线程可以多次获取,但是同一时刻只有一个线程能获取成功。
读写锁
读写锁维护了一对锁,一个读锁和一个写锁,在同一个时刻可以允许多个读线程访问,但是在写线程访问时,除了当前线程,其他所有线程的读/写操作均被阻塞。所以读锁是个共享锁,而写锁是个独占锁(排他锁),但它们都是重入锁。
读写锁和重入锁类似,都是用同步状态来表示获取锁的次数,而读写锁的表示更复杂些:用同步状态的高 16 位表示读锁获取次数(简称读次数),用低 16 位表示写锁获取次数(简称写次数):
1 | static final int SHARED_SHIFT = 16; |
读写锁实现了 ReadWriteLock 接口,ReadWriteLock 定义:
1 | public interface ReadWriteLock { |
读写锁内部实现是基于队列同步器(AbstractQueuedSynchronizer),同时也支持获取锁时的公平性与非公平性选择,可通过其构造方法进行设置,默认是非公平的。
1 | public class ReentrantReadWriteLock implements ReadWriteLock, java.io.Serializable { |
构造方法中默认创建了读/写锁对象,所以调用 readLock()
和 writeLock()
就可以获取读锁和写锁。
公平性与非公平性
读写锁默认也是非公平性的,所以先看看非公平性的实现:
1 | static final class NonfairSync extends Sync { |
writerShouldBlock()
方法直接返回了 false,而 readerShouldBlock()
方法稍微做了下判断,按照重入锁的非公平性实现,此时应该直接返回 false,为什么要有这个判断呢?因为读锁是可以被多个线程获取的,如果某个线程又获取了写锁并更新了数据,那么这个更新对其他获取读锁的线程是不可见的。
再看看公平性的实现:
1 | static final class FairSync extends Sync { |
这个就简单了,直接返回是否还有前驱节点,是我们想要的。
写锁
读写锁中用 WriteLock 来表示写锁,它是读写锁的内部类,定义如下:
1 | public static class WriteLock implements Lock, java.io.Serializable {...} |
获取写锁
通过写锁的 lock()
方法可以获取写锁,下面先看看写锁的获取:
1 | public void lock() { |
在 tryAcquire(int acquires)
方法中,writerShouldBlock()
需要分以下情况:
- 公平获取:
- 有前驱节点,返回 true,即不能获取写锁
- 没有前驱节点,返回 false
- 非公平获取:直接返回 false
所以,假如读/写锁被获取过了(c != 0
),此时如果读锁还没有被获取过或者被当前线程获取过,那么此次获取成功,否则此次获取失败;假如读/写锁都还没有被获取过(c == 0
),此时如果是公平获取,当存在前驱节点时获取失败,否则获取成功,如果是非公平获取,那么将获取成功。
1 | st=>start: Start |
释放写锁
调用写锁的 unlock()
方法可以释放写锁:
1 | public void unlock() { |
写锁的释放相对简单,每释放一次锁同步状态就减 1,当写锁的获取次数为 0 时表示真正释放成功。
读锁
读写锁中用 ReadLock 来表示读锁,它也是读写锁的内部类,定义如下:
1 | public static class ReadLock implements Lock, java.io.Serializable {...} |
获取读锁
通过读锁的 lock()
方法可以获取读锁:
1 | public void lock() { |
tryAcquireShared(int unused)
方法中, readerShouldBlock()
方法同样需要分情况:
- 公平获取:
- 有前驱节点,返回 true
- 没有前驱节点,返回 false
- 非公平获取:如果队列的第一个(头节点的后继节点)节点是独占式(写线程),返回 true,否则返回 false
这里出现了两个对象:cachedHoldCounter、readHolds,看看它们的定义:
1 | static final class HoldCounter { |
根据 ThreadLocal 的特性,所以此处每个线程都有一个自己的 HoldCounter,HoldCounter 里面记录的是当前线程获取锁的次数和当前线程的 ID。
回到 tryAcquireShared(int unused)
方法,什么时候会进入 fullTryAcquireShared(current)
方法呢?
- 假设线程 A 获取了写锁,当线程 B 来获取读锁的时候将被阻塞,这时如果线程 A 来获取读锁就进入该方法;
- 假设线程 A 获取了写锁,当线程 B、C 来获取读锁的时候将被阻塞,同步队列为 B->C,当 A 释放锁时 B、C 就会进入该方法
1 | final int fullTryAcquireShared(Thread current) { |
fullTryAcquireShared(Thread current)
主要处理读线程被阻塞和 CAS 失败的情况,基本上和 tryAcquireShared(int unused)
方法差不多。
所以,整个 tryAcquireShared(int unused)
方法可以分为三部分:
- 假如写锁被获取过了,此时如果当前线程和获取写锁的线程不同,那么获取失败;假如写锁没有被获取过,或者当前线程和获取写锁的线程相同都有可能获取成功。
- 假如是公平获取,此时如果存在前驱节点,那么获取失败,否则获取成功;假如是非公平获取,此时如果队列的第一个(头节点的后继节点)节点是独占式(写线程),那么获取失败,否则获取成功。
- 假如以上述第一种情况进入该方法,就执行
exclusiveCount(c) != 0
的逻辑;而以第二种情况进入的话,如果此时是线程 C 并且以公平获取的方式,那么由于在队列中还有前驱节点 B,导致此次获取失败。
释放读锁
调用读锁的 unlock()
方法可以释放读锁:
1 | public void unlock() { |
每次释放读锁都会给相应的状态减 1,这个和写锁的释放一样,但是有一点不同的是读锁的释放需要判断写锁是否释放完成。
每次获取写锁的时候修改同步状态都是 +1,释放写锁的时候都是 -1;而获取读锁时是 +SHARED_UNIT,释放读锁是 -SHARED_UNIT。当一个线程获取了写锁后再获取了读锁,在释放锁时如果没有先释放写锁,那么 nextc == 0
将永远不会成立。
所以如果当前线程获取了写锁和读锁,在释放锁的时候必须先释放写锁再释放读锁。
锁降级
读写锁中还有个锁降级的概念,意思就是把写锁降级成为读锁。如果一个线程获取了写锁并释放后再去获取读锁,这个过程不能称为锁降级。锁降级是指在获取了写锁后,在释放写锁之前去获取读锁,然后才释放写锁。看个示例:
1 | class CachedData { |
上述示例中,在释放写锁之前去获取读锁,然后才释放写锁。这个过程其实就是:一个线程获取写锁后仍然可以获取读锁,而其他线程的读/写都不能获取。
读写锁不支持锁升级,也就是读锁升级成为写锁。因为读锁是共享的,可以被多个线程获取,如果多其中某个线程又获取了写锁并更新了数据,这个更新对其他获取读锁的线程是不可知的。其实从上面的分析获取写锁的过程也能发现:如果其他线程获取了读锁,那么写锁将获取失败。
总结
重入锁和读写锁内部的功能实现都是基于队列同步器(AbstractQueuedSynchronizer),它们都是可重入的,即同一线程可以多次获取。但重入锁是排他锁,即同一时刻只允许一个线程获取成功;在读写锁中,读锁是共享锁,允许多个线程获取,而写锁是排他锁,同一时刻只允许一个线程获取成功。此外,当一个线程获取了写锁后还可以继续获取读/写锁,但是其他线程获取读/写锁将被阻塞。